查看原文
其他

Gradle既要、又要、还要的多模块的调试方案

yechaoa 鸿洋
2024-08-24

本文作者


作者:yechaoa

链接:

https://juejin.cn/post/7354940230301696009

本文由作者授权发布。


1前言

依赖替换不只是运用在依赖管理上,多数情况下,是来解决模块化架构下的多人协作问题、开发效率和编译提速问题。

1.1、模块化

模块化是指将app分割成独立的模块,每个模块都是一个可以独立编译、测试、打包的单元,比如商城项目中的user、goods、order等,都可以分割成一个独立的模块。这样做的好处是可以更加专注在自己负责的业务上,提高了代码的可维护性,和开发效率。
每个模块(Module)都是一个Project,都有自己的build.gradle文件,都可以定义自己的依赖和构建配置。

1.2、多模块的问题

既然每个模块都是一个Project,那么在编译的时候,Gradle的生命周期就都要去执行一遍,如果有几百个模块,这个耗时是非常恐怖的,而且在多人多团队的协作下,这是无意义的,因为你也不用关心别人负责的模块,只要关注自己的模块编译运行就好了。
目前主流的大型项目开发模式就是一个壳工程加上若干个子模块组合而成,子模块发布aar到远端仓库,然后壳工程根据坐标引入,子模块有新发布的话,壳工程更新一下版本就好了,或者直接拉取最新的基线同步一下版本。
问题在于,如果每次都要发布新版本才能验证代码的话,这是非常影响效率的,而且如果需要debug调试的话,也很不方便。

1.3、解法

有没有什么办法,既不增加编译耗时,也不影响开发效率,调试起来还方便的方案呢?
有,就是本文要介绍的依赖替换的功能。
上面的开发模式落在个人身上呢,一个人,加上一个壳工程,再加一个负责的模块,就是最小的开发单元了,如果在开发时,负责的模块能是源码依赖的,那就符合既要又要还要的诉求了。
而依赖替换的功能,就可以通过本地源码依赖和远端依赖的灵活切换实现这种诉求。
默认情况下,所有子模块全部使用远端依赖,即坐标GAV的方式:
implementation 'com.github.yechaoa.GradleX:plugin:1.5'


本地开发则切换到工程project的依赖方式:

implementation project(':plugin')


通过这种灵活切换可以实现每个开发同学一个壳加一个模块的本地开发模式,所以,模块化的架构不仅提升了可维护性和开发效率,也大大提升了建效率。

2依赖替换

2.1、if else

通过上面的分析,我们可以知道依赖替换本质上就是两种不同的依赖方式,那么就可以直接使用最原始简单的方式,通过if else大法来实现。

这里以我另一个开源库YUtils为例,把它作为一个模块依赖到GradleX项目中来。

https://github.com/yechaoa/YUtils


2.1.1、配置

我们可以定义一个变量来判断使用远端依赖还是本地依赖。
1. 在local.properties文件中定义一个变量useLocal,并赋值为true。
useLocal=true

local.properties文件中定义的好处是,不会产生代码变更,仅对本地开发生效,不用担心提交错了导致的编译失败而被同事鞭尸的问题。
2. 在build.gradle文件中取值并判断。
def localProperties = new Properties()
file("../local.properties").withInputStream { localProperties.load(it) }
def useLocal = localProperties.getProperty('useLocal', 'false').toBoolean()

if (useLocal) {
    implementation project(':yutilskt')
else {
    implementation 'com.github.yechaoa.YUtils:yutilskt:3.4.0'
}


上面代码表示,有useLocal属性且值为true的情况下,使用远端依赖,否则使用本地依赖。

所以本地开发的时候,只需要把useLocal改为true,重新sync即可。
还有一个小技巧,为了方便其他地方也使用local.properties中的useLocal属性,可以把这个读取操作抽成一个方法,除了可以复用之外,local.properties文件的路径参数也是固定不变的了,方便很多。
除了手动改useLocal的值以外,命令行编译的时候也可以加上参数传递来切换:
./gradlew assembleDebug -PuseLocal=true


命令行的属性(-P)优先级是要高于Gradle属性(xxx.properties)的。
在云编译的时候可以改为false。
3. settings.gradle文件中配置include,让模块参与编译。
def localProperties = new Properties()
file("local.properties").withInputStream { localProperties.load(it) }
def useLocal = localProperties.getProperty('useLocal', 'false').toBoolean()

if (hasProperty('useLocal') && getProperty('useLocal').toBoolean()) {
    include ':yutilskt'
}


include默认会从项目根目录去找,如果模块是独立的存储位置,即模块的位置不是在项目的根目录下,那么引进来的时候需要指定一下路径projectDir,告诉Gradle去哪找它。

if (hasProperty('useLocal') && getProperty('useLocal').toBoolean()) {
    include ':yutilskt'
    project(':yutilskt').projectDir = new File('../YUtils/yutilskt')
}


2.1.2、属性定义
这里有一个插曲,在子模块yutilskt中,依赖了kotlin的标准库,其版本是这么指定的:
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"


不是明文指定版本,而是通过索引的方式,而这个kotlin_version的定义是在yutilskt模块的父项目中定义的:
ext.kotlin_version = '1.7.10'


所以现在就会有一个问题,当yutilskt模块include引入到GradleX项目中的时候,如果GradleX项目中没有声明kotlin_version的话,就会出现yutilskt模块中的kotlin标准库找不到版本而导致编译失败的情况。

因为GradleX项目是基于Gradle 7.0以上创建的,而Gradle 7.0以上如果你项目的编程语言选的是Kotlin,那么kotlin就是内置的,不需要显式声明一堆依赖了,只有一个插件依赖了。
即:
plugins {
    id 'org.jetbrains.kotlin.android' version '1.7.10' apply false
}


所以这种情况下,子模块yutilskt中的kotlin_version属性就也要在GradleX项目中也定义一份。

只要能找到这个属性就行,我们可以不使用ext,在gradle.properties中定义也是一样的。
kotlin_version=1.7.10


在子模块和壳工程有共享配置的情况下,推荐使用include,比较独立的话用includeBuilding。

2.1.3、效果

现在项目的根目录结构如下:
.
├── app
├── build
├── buildSrc
├── gradle
├── plugin
└── ...


我们运行一下再来看看:

可以看到yutilskt模块已经是源码依赖的方式引入进来了。
有一个非常直观的验证方式,因为是源码依赖,所以会参与源码编译,只要看编译的日志中有相关的Task执行就可以了。
常规来说,源码依赖是需要把模块拷到项目的根目录下的,就像我们平时新建Module一样,而现在yutilskt模块并没有拷进来,而是通过在settings.gradle文件中定义的路径,让Gradle找到它并索引进来的,这种可称之为「软引用」。

2.1.4、版本控制

当yutilskt模块通过源码依赖的方式引入到GradleX项目中之后,我们也是可以直接对文件进行修改的,但同时又因为yutilskt模块是一个外部的模块,就导致代码的修改不会在GradleX这个项目中引起文件的变更,File Status不会变化,即代码修改之后文件不会变成蓝色,非常影响日常的开发效率。
这是因为GradleX项目中的目录映射(Directory Mappings)里面没有yutilskt模块,所以yutilskt模块在GradleX项目中没有版本控制,我们可以在设置中把yutilskt模块加进来就好了。(又一个小技巧)

2.2、substitute

上面是一个简易的依赖替换方式,Gradle也提供了依赖替换的能力,在依赖解析策略中可以使用substitute方法来进行本地源码依赖和远端依赖的替换,功能也比if else更丰富一些。

在前面的第6章里,我们详细介绍了依赖解析相关的内容,依赖替换跟依赖解析有点像,它们都是依赖管理的一部分,都是用来定义依赖的能力,它们最大的区别在于,依赖替换可以允许项目和依赖项的相互替换,即源码和二进制依赖的相互替换。

https://juejin.cn/post/7215579793261117501


2.2.1、语法

substitute的语法如下:
substitute <dependency> using <replacement>


其中:

  • 要替换的依赖项;
  • 替换后的依赖项;
后者替换前者;
Gradle 6.6版本及以后,using方法替代了with方法。
示例:
substitute project(':yutilskt') using module('com.github.yechaoa.YUtils:yutilskt:3.4.0')


这段代码表示,把源码项目project(':yutilskt')替换成二进制远端依赖com.github.yechaoa.YUtils:yutilskt:3.4.0
  • project():项目,表示源码依赖,格式为:
project(':path')

  • module():模块,表示二进制依赖,即发布远端通过GAV引入的依赖,比如AAR,格式为:
module('{group}:{module}:{version}')

version可以不带。

2.2.2、使用

resolutionStrategy.dependencySubstitution {
    // 把yutilskt替换成远端依赖
    substitute project(':yutilskt') using module('com.github.yechaoa.YUtils:yutilskt:3.4.0')
    // 把yutilskt:3.4.0替换成源码依赖
    substitute module('com.github.yechaoa.YUtils:yutilskt:3.4.0') using project(':yutilskt')
    // 把yutilskt:3.3.3替换成yutilskt:3.4.0
    substitute module('com.github.yechaoa.YUtils:yutilskt:3.3.3') using module('com.github.yechaoa.YUtils:yutilskt:3.4.0')
}


所以,我们可以使用substitute实现以下功能:
  • 将源码依赖替换成远端依赖;
  • 将远端依赖替换成源码依赖;
  • 将远端依赖替换成另一个版本;

2.2.3、实践

其实上面的代码已经是实践要用到的代码了,即:
resolutionStrategy.dependencySubstitution {
    // 把yutilskt:3.4.0替换成源码依赖
    substitute module('com.github.yechaoa.YUtils:yutilskt:3.4.0'with project(':yutilskt')
}

我们只要稍稍改一下就可以了,加上使用条件,我们复用一下前面的useLocal属性。
  1. local.properties中配置:
useLocal=true

或编译时执行:
./gradlew assembleDebug -PuseLocal=true
  1. 添加替换的判断校验

def localProperties = new Properties()
file("../local.properties").withInputStream { localProperties.load(it) }
def useLocal = localProperties.getProperty('useLocal', 'false').toBoolean()

resolutionStrategy.dependencySubstitution {
    if (useLocal) {
        substitute module('com.github.yechaoa.YUtils:yutilskt:3.4.0') using project(':yutilskt')
    }
}

上述代码表示,如果有useLocal属性,且值为true,就把远端依赖yutilskt替换成本地源码依赖。
然后我们还需要再添加一下yutilskt远端依赖:
implementation 'com.github.yechaoa.YUtils:yutilskt:3.4.0'


毕竟你得先有依赖,才能给你替换啊对吧。
然后,需要注意的是,使用源码依赖同样也需要引入到项目中参与编译,也就是settings.gradle中的include配置。
  1. 配置include
if (useLocal) {
    include ':yutilskt'
    project(':yutilskt').projectDir = new File('../YUtils/yutilskt')
}

至此就配置完了,下面我们看下效果。

2.2.4、效果

我们在编译的日志里可以看到有yutilskt项目相关的task执行,就表示yutilskt项目已经是源码依赖了。

2.3、进阶

虽然我们现在通过在local.properties文件中定义useLocal属性,可以做到源码依赖和远端依赖的灵活切换。
但是也存在着一个问题,就是当有模块新增的时候,我们要改两个地方:
  1. settings.gradle文件中的include配置。
  2. build.gradle文件中的substitute配置。
每次都要修改这两个地方,虽然简单也没什么工作量,但是不太优雅,我们可以优化一下,增加一个协议层,实现一处修改两处生效的效果。
先说下我用过的一种方案,在local.properties文件中定义模块名称,比如:
app.yutilskt=yutilskt


然后模块clone到本地的路径保持跟壳工程路径同一层级,这样就可以在固定的路径找到local.properties文件中定义的模块,从而进行切换操作了。

这个方案的优点就是配置简单,但是问题也很明显,路径不对就失效了,模块中嵌套的模块因为路径问题也会失效,所以,不如考虑一个通用的方案。

2.3.1、拆解诉求

先来带大家拆解一下诉求:
  1. settings.gradle文件中的include配置我们可以Hook Gradle的生命周期,拿到Settings对象进行动态添加include;
  2. build.gradle文件中的substitute配置我们同样可以Hook拿到Configuration对象进行添加;
  3. 定义一个协议文件,yml、xml、json啥的都可以,其中包含一些常用字段;
  4. 然后Settings对象和Configuration对象对这个文件进行解析、添加。
拆解下来就会发现诉求其实并不复杂,实际上也很简单。
对Gradle生命周期不熟的,可以去看看前面的第4章
https://juejin.cn/post/7170684769083555877
下面来带大家一起实操一下。

2.3.2、定义协议文件

我们就在项目的根目录定义一个useLocal.json文件,里面是一个需要源码依赖的模块信息数组。
字段的定义呢,按需来就好了,看我们用到什么就定义什么。
首先settings.gradle文件中,我们需要用到模块名称和模块路径,其次在build.gradle文件中做依赖替换的时候,需要用到模块名称和模块的远端坐标GAV(version也可以不要的),总结下来就3个字段,名称、路径和坐标,我们也可以把useLocal=true这个属性下放到每个模块,增加灵活性。
所以,最后的定义如下:
[
  {
    "useLocal"true,
    "moduleName""yutilskt",
    "modulePath""../YUtils/yutilskt",
    "moduleGav""com.github.yechaoa.YUtils:yutilskt:3.4.0"
  }
]


2.3.3、编写插件

class UseLocalPlugin implements Plugin<Settings> {
    @Override
    void apply(Settings settings) {

    }    
}


因为include是在settings初始化的阶段,所以这里直接使用Settings对象,而不是Project对象。

2.3.4、Hook Settings

class UseLocalPlugin implements Plugin<Settings> {
    @Override
    void apply(Settings settings) {

        // Gradle初始化,此时可以获取到Settings对象
        settings.gradle.settingsEvaluated {
            // 加载useLocal.json文件
            def useLocalFile = new File(settings.getRootDir(), "useLocal.json")
            if (!useLocalFile.exists()) {
                println("useLocal.json文件不存在")
                return
            }
            def useLocalText = useLocalFile.text

            // 解析JSON文本
            def jsonSlurper = new JsonSlurper()
            def useLocalData = jsonSlurper.parseText(useLocalText)

            // include
            useLocalData.each { item ->
                if (item.useLocal) {
                    settings.include(":${item.moduleName}")
                    settings.project(":${item.moduleName}").projectDir = new File(item.modulePath)
                }
            }

            // 依赖替换
            SwitchAarToCode(settings.gradle, useLocalData)

        }
    }
}


  1. settings.gradle.settingsEvaluated { }回调中可以拿到初始化好的settings对象;
  2. 解析我们的协议json文件;
  3. 再通过settings对象进行include;
  4. 最后调用SwitchAarToCode()方法进行依赖替换。

2.3.5、Hook Configuration

def SwitchAarToCode(gradle, useLocalData) {
    // 所有Project对象evaluate完毕之后,会回调gradle.projectsEvaluated
    gradle.projectsEvaluated {
        gradle.allprojects { pro ->
            // 这个app可以根据你的项目名称来判断
            if (pro.name == "app") {
                pro.configurations.all { configuration ->
                    configuration.resolutionStrategy.dependencySubstitution { substitutions ->
                        useLocalData.each { item ->
                            if (item.useLocal) {
                                substitute module(item.moduleGav) using substitutions.project(":${item.moduleName}")
                            }
                        }
                    }
                }
            }
        }
    }
}
  1. 依赖解析是在配置阶段,所以我们需要在gradle.projectsEvaluated { }回调中拿到project的configuration对象;

  2. if (pro.name == "app") 这个app大家可以根据自己的主项目名称来判断;
  3. 最后调用substitute 进行依赖替换。

2.3.6、依赖插件

最后,别忘了在settings.gradle文件中依赖我们编写的插件:
apply plugin: UseLocalPlugin


到此,我们的代码就写完了,下面运行看下效果。

2.3.7、效果

可以看到效果已经实现了。

2.3.8、小结

目前这个插件方案虽然比较基础,不过也够用了。

实际项目中,useLocal.json文件可以先内置一些常用的模块,并把所有的useLocal默认设置为false,然后先提交一份,再把文件添加到.gitignore里,这样其他同学只要修改一下useLocal的值然后sync一下,就能快乐的进行源码切换了。

3总结


本章主要给大家介绍了Gradle中的依赖替换功能,以提高模块化架构下的多人协作效率和编译速度。
依赖替换有多种方案,除了if else大法,还有本文未提及的useTarget,不过还是推荐使用substitute。
substitute的用法上面已经介绍的很详细了,包括进阶部分还带大家一起编写了一个插件来简化操作,相信你也差不多有个基本的概念了,赶紧实操试试吧。
总的来说,依赖替换对于复杂点的项目的开发还是非常有帮助。如果你用不上,学习点Gradle的进阶用法也不是坏处,万一用得上呢。
写作不易~

GitHub

https://github.com/yechaoa/GradleX

相关文档

Customizing resolution of a dependency directly

https://docs.gradle.org/current/userguide/resolution_rules.html#sec:dependency_substitution_rules

【Gradle-4】Gradle的生命周期

https://juejin.cn/post/7170684769083555877

【Gradle-6】一文搞懂Gradle的依赖管理和版本决议

https://juejin.cn/post/7215579793261117501




最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

Android存储系统成长记,来认识我的团队成员
Android视频开发入门: VideoView、MediaPlayer、 FFmpeg、exoplayer...
OpenHarmony源码解读系列:ArkUI Engine 导读


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存